Skip to content

[#993] Register an entry container's configuration listeners only once it has opened - #999

Open
vharseko wants to merge 3 commits into
OpenIdentityPlatform:masterfrom
vharseko:issue-993-entry-container-listeners-on-open
Open

vharseko wants to merge 3 commits into
OpenIdentityPlatform:masterfrom
vharseko:issue-993-entry-container-listeners-on-open

Conversation

@vharseko

@vharseko vharseko commented Sep 9, 2026

Copy link
Copy Markdown
Member

Fixes #993.

The defect

EntryContainer registered itself and its two configuration managers as listeners of the backend
configuration from its constructor, and only close() takes them off again. open() caught
StorageRuntimeException alone, while it is declared to throw ConfigException and really does -
an index type the attribute has no matching rule for, an index protecting both its keys and its
values, and (the most reachable of the three) a VLV filter or sort order which does not parse.
Neither RootContainer.openEntryContainer nor openAndRegisterEntryContainers catches it either,
so the container is registered nowhere and nothing will ever call its close().

Two things in the report needed correcting, and both are in the analysis on the
issue
: the
failure is not a NullPointerException - id2entry is assigned by the first statement of the
try - and it is more than five listeners, since every index the failed open got through
registered one of its own. What it costs is worse than an NPE:
ConfigurationHandler.replaceEntry asks every listener on the backend entry whether a change is
acceptable and a single false rejects the whole modify, so an abandoned container can veto a
change on the live backend; and past that gate the entry is already stored, so a failure from one
listener turns a change the live container applied into ERR_CONFIG_FILE_MODIFY_APPLY_FAILED.

The change

EntryContainer

  • the five registrations move from the constructor to the end of a successful open(). A container
    which did not open is not one a configuration change has anything to be applied to, and nothing
    can reach it in between: open() is called before anything holds it.
  • open() catches every failure rather than the storage ones alone, so a ConfigException also
    goes through close().
  • each index is held in its map before it is opened. An attribute index registers its listener at
    the end of open() and a VLV index from its constructor, so the one being opened was not yet one
    close() could find - the hole the StorageRuntimeException catch already had.

RootContainer

  • a failed open() gives back what it took: the entry containers it registered, its own listener,
    and the storage - the last unless it was the storage's own open() that threw. A storage whose
    open failed is not one the root container can close: what that open took before it failed is the
    storage's own to give back (below), and there is no other road to that arm - every root container,
    read only or not, is opened over a storage no root container holds, since BackendImpl opens one
    only while it has none. Nothing else reclaims any of it: newRootContainer throws the instance
    away and BackendConfigManager releases the shared lock without calling closeBackend() for a
    backend which never opened, so a volume left open here is one no later attempt to enable that
    backend can take.
  • an entry container is registered as soon as it has opened, before its highest entry ID is read: a
    container which opened has registered every listener it ever will, and only what the registry
    holds is given back, so a cursor which fails between the two left one nothing holds.
  • openAndRegisterEntryContainers runs inside the write Storage.write may replay, so it gives up
    what a rolled back attempt registered before opening again. Without it an ordinary write-write
    conflict during startup fails the backend with ERR_ENTRY_CONTAINER_ALREADY_REGISTERED and
    leaves that attempt's containers registered - the same shape BackendImpl.changeBaseDNTrees
    already uses for the containers it opens inside a write.

PDBStorage and JEStorage

  • an open() which fails gives back what it took before it failed, as JDBCStorage.open already
    does: the cache size buildConfiguration drew from the memory quota and the listener the
    constructor registered on the backend configuration, and the database itself when the open got
    that far. Every failed enable of a PDB or JE backend - its volume held by the storage a previous
    failed enable left behind, its directory unwritable - drained one cache size for the life of the
    JVM and left a storage answering that backend's configuration changes; and the drained quota is
    what every isConfigurationChangeAcceptable answers from, so a cache size the server has the
    memory for is refused. startImport() shares the give-back.
  • the guard against opening a database which is open runs before anything is taken, so that the
    give-back never has a live database in front of it.
  • close() releases the quota once, whatever follows: a close() after a give-back - or the second
    and third close() importLDIF has always made on the storage of its root container - releases
    nothing more, and it survives a database the failed open registered no monitor for.
  • close() gives back the quota, the listener and the monitored directory ahead of the database, so
    that a database whose own close fails keeps nothing else.

The two storages had the same shape, so they get the same change; the difference is in what a failed
open meets. A locked directory is not a JE road inside a JVM - DbEnvPool shares the environment
among the handles a process opens over one directory - so the JE test fails its open on a directory
the server cannot use.

The base DN path needs nothing of its own: a container whose open() throws now closes itself, so
the container changeBaseDNTrees never gets to put in created is reclaimed too.

Tests

FailedBackendOpenTest, eight tests, each watched to fail first - against the tree before the change
for the first five, against a mutant of the change for the three which pin what it keeps:

test what it reported
aBackendWhichFailsToOpenLeavesNothingRegistered the root container, the entry container and both configuration managers left registered
anIndexWhichFailsToOpenLeavesNoListenerBehind /dc=com,dc=b993/vlv.vlv1 left registered
aBackendWhichFailsToOpenGivesBackTheStorageItOpened closes: expected 1, was 0
aRootContainerWhichCouldNotOpenTheStorageDoesNotCloseIt an early return when the storage did not open leaves the root container registered
aReplayedOpenLeavesOneSetOfEntryContainers An entry container named 'dc=com,dc=b993' is alreadly registered
aSecondBaseDNWhichFailsToOpenGivesBackTheFirst the loop of giveUpAfterFailedOpen deleted: the first base DN's container and its five registrations left behind
anEntryContainerWhichOpenedButWasNotRegisteredIsGivenBack the entry container and its four manager registrations left behind when the read of the highest entry ID fails
anEntryContainerWhichOpenedIsRegisteredOnce the five registrations deleted at their new site: green everywhere else; registered from the constructor as well: 2 where 1 is expected

A case whose expected failure does not come closes the backend it opened after all, so the case
which follows fails on its own assertion rather than on a base DN the previous one left registered.

PDBStorageTest, four tests, each watched to fail first:

test what it reported before the change
aStorageWhoseOpenFailedGivesBackWhatItTook 76 MB less of the quota available after the failed open
closingAStorageWhoseOpenFailedTakesNothingMore 76 MB more than before once the give-back was in and close() still released a second time
openingAnOpenStorageIsRefusedAndTakesNothing 76 MB less of the quota available after the refusal
aStorageWhoseOpenFailedAfterItsDatabaseOpenedGivesTheDatabaseBack against three mutants of the give-back past the database open: the quota 76 MB short, the monitor still registered, the volume still locked on the next open

JEStorageTest, three tests, the twins of the first three, each red on the corresponding mutant of
JEStorage: the give-back removed, the quota released on every close, the double-open guard back
after the quota is taken.

Run green with them: PDBTestCase, EncryptedPDBTestCase, JETestCase, EncryptedJETestCase,
ReplayedConfigChangeTest, OnDiskMergeImporterTest, PersistentCompressedSchemaTest, DN2IDTest,
StateTest, ID2EntryTest, ID2ChildrenCountTest, BulkCursorTest, DefaultIndexTest,
ImportLDIFTestCase, RebuildIndexTestCase, VerifyIndexTestCase.

…n listeners only once it has opened

EntryContainer registered itself and its two configuration managers from its
constructor, and only close() takes them off again. open() caught
StorageRuntimeException alone, so a ConfigException - a VLV filter or sort order
which does not parse, an index type the attribute has no matching rule for - left
a container nothing holds a reference to, registered on the configuration of a
backend which did not start.

Register the five at the end of a successful open() instead, and catch every
failure there rather than the storage ones alone. Hold each index in its map
before opening it: an attribute index registers its listener at the end of
open() and a VLV index from its constructor, so the one being opened was not yet
one close() could find - the hole the existing catch already had.

RootContainer gives back what a failed open took: the entry containers it
registered, its own listener and the storage, the last only when this call is
what opened it. openAndRegisterEntryContainers runs inside a write the storage
may replay, so it now gives up what a rolled back attempt registered before
opening again; without it a write-write conflict failed the backend with
ERR_ENTRY_CONTAINER_ALREADY_REGISTERED.

Fixes OpenIdentityPlatform#993
@vharseko
vharseko requested a review from maximthomas September 9, 2026 13:59
@vharseko vharseko added bug tests Test suites: fixing, enabling, un-disabling labels Sep 9, 2026

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: The PR closes the leak it names and proves it: four of the five cases are red at BASE exactly as the table says (run here: tests=5 failures=4), and the fifth kills the storageOpened-forced-true mutant.

  • The replay give-back at the top of openAndRegisterEntryContainers closes the ERR_ENTRY_CONTAINER_ALREADY_REGISTERED road traced on #883, and aReplayedOpenLeavesOneSetOfEntryContainers pins it both ways: delete the loop → ALREADY_REGISTERED; unregister without close → stillRegisteredOn(cfg) non-empty.
  • catch (Exception e) { close(); throw e; } is pinned by aBackendWhichFailsToOpenLeavesNothingRegistered through the index listener, and EntryContainer.close() has no throwing path (every callee read), so the original exception is the one that escapes.
  • The issue analysis corrected the report — not an NPE, more than five listeners — before the fix was written.

issue (non-blocking): The entry-container loop in giveUpAfterFailedOpen is pinned by no case — deleting it is green 5/5.

opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/RootContainer.java:191-194

Every fixture has one base DN and openEntryContainer runs before registerEntryContainer, so the failing container is never in entryContainers: tests 1-3 iterate an empty map, test 4 fails in storage.open, test 5 succeeds. Measured: the loop removed, FailedBackendOpenTest passes 5/5. The road it exists for — first base DN registered, the second's open fails — is untested. RefusingOneTree matches on getIndexId() alone, so it cannot single out the second container's tree; match on the full name.

// TrackingStorage / RefusingOneTree: refuse by full tree name, not index id alone
if (refused.equals(name.toString()))          // "/dc=com,dc=b993b/id2entry"

@Test
public void aSecondBaseDNWhichFailsToOpenGivesBackTheFirst() throws Exception
{
  final TrackedBackend backend = new TrackedBackend();
  backend.setBackendID(BACKEND_ID);
  final PDBBackendCfg cfg = backendCfg(newTreeSet(BASE_DN, DN.valueOf("dc=b993b,dc=com")));
  backend.configureBackend(cfg, serverContext);
  backend.storage.removeStorageFiles();
  backend.storage.failOpeningTree("/dc=com,dc=b993b/id2entry");
  try
  {
    backend.openBackend();
    fail("the backend was expected not to open with a second base DN whose trees cannot be opened");
  }
  catch (InitializationException expected)
  {
  }
  finally
  {
    backend.storage.close();
  }
  assertThat(stillRegisteredOn(cfg)).isEmpty();        // the first container's five
  assertThat(stillRegisteredOn(indexCfg)).isEmpty();   // and its cn index
}

Pin: with the loop deleted this case must go red on stillRegisteredOn(cfg).


issue (non-blocking): A storage.open() which throws leaves the PDB quota, the PDB change listener and a half-built Persistit behind, and the reason the javadoc gives for not closing it is not the real one.

opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/RootContainer.java:137-138, :182-185, :196-199
opendj-server-legacy/src/main/java/org/opends/server/backends/pdb/PDBStorage.java:1060, :1085-1095, :1172-1199, :1103-1119

Before open0 can throw, PDBStorage has already registered itself as a PDB change listener (constructor, :1060), acquired dbCacheSize from the MemoryQuota (buildConfiguration, :1090/:1095) and assigned db = new Persistit(dbCfg) (:1182); the catches at :1193-1199 only rewrap. With storageOpened == false nothing gives that back, and RootContainer cannot "always close": PDBStorage.close() with db != null and monitor == null NPEs in DirectoryServer.deregisterMonitorProvider(null) (:1107) before db.close() and the quota release. Every failed enable of a PDB backend whose directory is unwritable, whose volume is corrupt or in use drains one dbCacheSize for the JVM's life and leaves a storage answering PDB config changes — #993 one class down. Pre-existing (BASE closed nothing on any failed-open road); JDBCStorage.open self-unwinds (:1059-1076).

The javadoc's rationale — "a read only root container is opened over the very storage instance the backend holds" — is a road that does not exist: every READ_ONLY open (BackendImpl.exportLDIF:620, verifyBackend:727, rebuildBackend:772, importLDIF:685) is gated on mustOpenRootContainer() i.e. rootContainer == null, so no live root container holds the instance, storage.open() genuinely opens it, and closing it on failure is correct. The false arm fires on exactly one road: storage.open() threw.

// PDBStorage.open: give back what buildConfiguration/open0 took before throwing
@Override
public void open(AccessMode accessMode) throws ConfigException, StorageRuntimeException
{
  Reject.ifNull(accessMode, "accessMode must not be null");
  if (isBackendIncomplete(accessMode))
  {
    return;
  }
  boolean opened = false;
  try
  {
    open0(buildConfiguration(accessMode));
    opened = true;
  }
  finally
  {
    if (!opened)
    {
      giveUpFailedOpen();   // db.close() best-effort + db = null; release memQuota; removePDBChangeListener(this)
    }
  }
}

Or: file it as a follow-up against PDBStorage — but in either case rewrite the storageOpened rationale in the giveUpAfterFailedOpen javadoc, the PR body, test 4's javadoc and comment (FailedBackendOpenTest.java:209-211, :229) and the mock message (:474) to the true one: a storage whose open() threw is not one this call can close, and what it took is the storage's own to give back.


issue (non-blocking): A container which opened but fails before registerEntryContainer is reclaimed by nobody.

opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/RootContainer.java:288-290

After openEntryContainer returns, the container has registered its five listeners plus one per index; ec.getHighestEntryID(txn) runs before registerEntryContainer(baseDN, ec). A StorageRuntimeException from that cursor read (Persistit.getExchange / Exchange.previous: TreeNotFoundException, PersistitIOException, CorruptVolumeException, TimeoutException) leaves ec a local: giveUpAfterFailedOpen and the replay loop walk entryContainers only, and EntryContainer.open's catch has returned. Not replayed — a fetch never raises RollbackException (both throw sites in Exchange.java are on the store path) — so one leaked set per attempt, and the server continues. Pre-existing window; the PR narrows every other road and leaves this one.

EntryContainer ec = openEntryContainer(baseDN, txn, accessMode);
registerEntryContainer(baseDN, ec);            // held before anything else here can throw
EntryID id = ec.getHighestEntryID(txn);

suggestion (non-blocking): No case asserts that an opened container is registered — the five registrations can be deleted at their new site and the class stays green.

opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/EntryContainer.java:561-566
opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/FailedBackendOpenTest.java:281-333

Measured: the five registrations moved back to the constructor, everything else at head — 5/5 green, because catch (Exception) { close(); } removes what the constructor added. That revert is unkillable by construction (constructor and open() are always paired), but the delete at the new site is pinnable, and stillRegisteredOn uses List.removeAll, which hides a double registration.

// positive twin, after a successful openBackend()
backend.openBackend();
final List<Object> registered = stillRegisteredOn(cfg);
assertThat(registered).filteredOn(l -> l instanceof EntryContainer).hasSize(1);
// the two private cfg managers, each once as add and once as delete listener: 4, not 0 and not 8
assertThat(registered).filteredOn(l -> l.getClass().getSimpleName().endsWith("IndexCfgManager")).hasSize(4);

// stillRegisteredOn: remove one occurrence per removal, so a double registration is visible
for (Object removed : changeRemoved.getAllValues())
{
  registered.remove(removed);
}

suggestion (if-minor): aRootContainerWhichCouldNotOpenTheStorageDoesNotCloseIt asserts closeCalls() == 0 only; the root container's own listener removal on the storageOpened == false arm is unpinned.

opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/FailedBackendOpenTest.java:232

An early return when !storageOpened at the top of giveUpAfterFailedOpen is green 5/5 by reading: the other four cases have storageOpened == true.

assertThat(backend.storage.closeCalls()).isEqualTo(0);
assertThat(stillRegisteredOn(cfg)).isEmpty();

…ok, and pin the give-back of the root container

Review round 2 of OpenIdentityPlatform#999.

PDBStorage.open() and startImport() give back what the attempt took before it
failed - the cache size buildConfiguration drew from the memory quota, the
listener the constructor registered on the backend configuration, and the
database when the open got that far - as JDBCStorage.open already does. Every
failed enable of a PDB backend drained one cache size for the life of the JVM
and left a storage answering the configuration changes of a backend which is
not running. The guard against a double open runs before anything is taken,
and close() releases the quota once and tolerates a database the failed open
registered no monitor for.

RootContainer registers an entry container as soon as it has opened, before
its highest entry ID is read: a container which opened has registered every
listener it ever will, and only what the registry holds is given back. The
rationale of the storageOpened arm is the true one: the storage's own open()
threw, and what that open took is the storage's own to give back - no root
container is ever opened over a storage another one holds.

FailedBackendOpenTest pins the give-back loop with a second base DN, the
registration of an opened container, the root container's own listener when
the storage did not open, and the positive twin - one registration of each
listener once a container has opened, counted per occurrence. PDBStorageTest
pins the give-back, a close() which follows it, and the refusal of a double
open.
@vharseko

Copy link
Copy Markdown
Member Author

All five taken, in 42f6aa2. Each pin was watched to fail on the mutant it is for before the case
went in, the PDB ones against the tree before the change.

The loop of giveUpAfterFailedOpen - aSecondBaseDNWhichFailsToOpenGivesBackTheFirst: two base
DNs, the second's id2entry refused, RefusingOneTree matching on the full tree name as suggested
(now TrackingTransaction, which also records the trees it opened, so the case asserts that the first
container had opened before the second one failed - the road it is about). Loop deleted:
stillRegisteredOn(cfg) reports the first container and its five registrations.

PDBStorage.open - in this PR rather than a follow-up: the PR claims a failed open gives back
what it took, and on the most reachable road - the volume held by the storage a previous failed
enable left behind, i.e. the second attempt of the very scenario in the issue analysis - that was
false. One correction to the trace: new Persistit(dbCfg) calls initialize() itself, whose
finally releases everything when it did not complete, so an InUseException or a corrupt volume
throws before db is assigned and leaves no half-built Persistit behind; that arm is reached only
when loadVolume or the monitor registration fails after the assignment. What every failed open0
did leave was the quota reservation of buildConfiguration and the listener of the constructor -
exactly as you said. The shape is the one you sketched, with the double-open guard hoisted ahead of
buildConfiguration (it used to take the quota and then refuse) so that the give-back never has a
live database in front of it, and close() now releases the quota once and tolerates a database
without a monitor - a close() after the give-back, or the second and third close() importLDIF
has always made on its root container's storage, must release nothing more. Three cases in
PDBStorageTest, each red first: the quota after a failed open over the volume setUp() holds
(76 MB short), the same followed by close() (76 MB over, once the give-back was in and the release
still ran twice), and the refusal of a double open (76 MB short). The monitor != null arm is the one
thing here with no case: reaching it needs loadVolume to fail on a database which just initialized.

The storageOpened rationale is rewritten to the true one in the javadoc, the case, its comment,
the mock message and the PR body: the false arm is storage.open() threw, and there is no other -
every READ_ONLY and READ_WRITE open in BackendImpl is gated on rootContainer == null, and
BackendStat configures a fresh backend before it asks for one.

The container opened but not registered - registerEntryContainer now runs first;
anEntryContainerWhichOpenedButWasNotRegisteredIsGivenBack refuses the first cursor over id2entry
once an entry container is registered on the configuration (the read of the highest entry ID; the
cursors before it - the emptiness check of open() and the one each untrusted index makes - fail the
open itself, which the container catches). Order reverted: the container and its four manager
registrations left behind.

The positive twin - anEntryContainerWhichOpenedIsRegisteredOnce: one EntryContainer, the two
managers twice each, one listener on the index and on the VLV index configuration.
stillRegisteredOn takes one occurrence off per removal. Registrations deleted at the new site: the
container count is 0; made from the constructor as well: 2 where 1 is expected, and the replay and
second-base-DN cases go red with it.

The listener of the root container on the !storageOpened arm - stillRegisteredOn(cfg) added
to aRootContainerWhichCouldNotOpenTheStorageDoesNotCloseIt; an early return there is red on it.

Green with the change: FailedBackendOpenTest 8/8, PDBStorageTest 13/13, and the set in the PR
body (PDBTestCase, EncryptedPDBTestCase, ReplayedConfigChangeTest, OnDiskMergeImporterTest,
PersistentCompressedSchemaTest, DN2IDTest, StateTest, ID2EntryTest, ID2ChildrenCountTest,
BulkCursorTest, DefaultIndexTest) plus ImportLDIFTestCase, RebuildIndexTestCase and
VerifyIndexTestCase for the import road close() now sees three times.

@vharseko vharseko added java Changes to Java sources index Attribute/VLV index subsystem: build, trust, rebuild, confidentiality labels Sep 16, 2026

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: The failed-open give-back now holds on every road the PR names, and each fix is pinned by a case that goes red without it.

  • registerEntryContainer before getHighestEntryID (RootContainer.java:291-297): reverting the order turns anEntryContainerWhichOpenedButWasNotRegisteredIsGivenBack red (1/8).
  • The give-back loop and the storageOpened arm of giveUpAfterFailedOpen (RootContainer.java:189-205): loop deleted → 2/8 red; early return on !storageOpened → 1/8 red.
  • PDBStorage.openOrGiveBack with rejectIfOpen ahead of buildConfiguration (PDBStorage.java:1159, :1182-1204), pinned by the three new PDBStorageTest cases; the memQuota = null comment (:1133-1135) names the import road's extra close, so the 3 → 2 release rebalance reads as intended.
  • CI green at this head on the Linux cells, where the failsafe suite runs.

question: Does the storage give-back stop at PDBStorage on purpose? JEStorage.open still leaks the quota and its change listener when open0 throws.

opendj-server-legacy/src/main/java/org/opends/server/backends/jeb/JEStorage.java:829-840, :719, :764-771, :866-873, :786-822; opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/RootContainer.java:182-187

open() runs buildConfiguration (quota acquired at :764-771) before open0; the double-open guard sits inside open0 (:866-869), after the quota was taken; new Environment(...) (:871) throwing leaves the quota taken and the ctor-registered listener (:719) in place, and nobody calls close()giveUpAfterFailedOpen(false) closes no storage by design. close() (:786-822) never nulls memQuota, so a second close releases twice. This is PDBStorage's shape at BASE; the PR body claims the give-back generally, the RootContainer javadoc names PDBStorage and JDBCStorage only. Pre-existing and untouched by the PR — a follow-up issue named in that javadoc is fine if the scope is deliberate; otherwise the PDBStorage moves mirror:

// JEStorage.open — refuse before taking; give back what a failed open0 took
if (env != null)
{
  throw new IllegalStateException(
      "Database is already open, either the backend is enabled or an import is currently running.");
}
buildConfiguration(accessMode, false);
boolean opened = false;
try
{
  open0();
  opened = true;
}
finally
{
  if (!opened)
  {
    try { close(); } catch (RuntimeException e) { logger.traceException(e); }
  }
}
// JEStorage.close — release once: the import road closes twice
memQuota.releaseMemory(...);
memQuota = null;

Keep the guard inside open0 as well — its :903 caller does not come through open().

Pin: the PDBStorageTest.aStorageWhoseOpenFailedGivesBackWhatItTook shape on a JEStorage over a locked directory — quota back to availableBefore, verify(cfg).removeJEChangeListener(second).


issue (non-blocking): A failed-open case whose failure does not come leaves the backend's base DN registered, and the next case dies in openBackend() instead of on its own assertion.

opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/FailedBackendOpenTest.java:286-315, :331

Measured: with the five registrations deleted from the end of EntryContainer.open(), anEntryContainerWhichOpenedButWasNotRegisteredIsGivenBack opens where it expected to fail; its finally closes the storage (:309) but not the backend, and anEntryContainerWhichOpenedIsRegisteredOnce went red on base DN dc=b993,dc=com already registered — its hasSize(1) (:346) was never reached (2/8 red, the second one for the wrong reason). The twin also calls openBackend() (:331) outside its try, where aReplayedOpenLeavesOneSetOfEntryContainers (:369-378) guards the same call.

boolean opened = false;
try
{
  backend.openBackend();
  opened = true;
  fail("the backend was expected not to open when the highest entry ID cannot be read");
}
catch (InitializationException expected)
{
  // What a storage which cannot position a cursor on the last entry does.
}
finally
{
  if (opened) { backend.closeBackend(); } else { backend.storage.close(); }
}

The same finally around :331.


issue (non-blocking): PDBStorage.close() skips the quota, listener and disk-monitor releases when db.close() throws.

opendj-server-legacy/src/main/java/org/opends/server/backends/pdb/PDBStorage.java:1113-1142

A PersistitException from db.close() (:1115) becomes the IllegalStateException at :1120 and leaves :1123-1142 unrun; on the give-back road openOrGiveBack's catch (:1201) traces it and the quota and the listener stay taken — the round-1 leak, one road narrower. Reachable only with db != null on that road (an Error after :1230), so defensive: the releases ahead of the db.close() block, or a finally.

@Override
public void close()
{
  if (memQuota != null) { /* the :1123-1136 block, memQuota = null included */ }
  config.removePDBChangeListener(this);
  if (diskMonitor != null)
  {
    diskMonitor.deregisterMonitoredDirectory(getDirectory(), this);
  }
  if (db != null) { /* :1105-1121, unchanged */ }
}

suggestion (non-blocking): The db != null arm of the give-back (monitor != null guard, db.close()) and the openOrGiveBack swallow run in no case.

opendj-server-legacy/src/main/java/org/opends/server/backends/pdb/PDBStorage.java:1105-1120, :1201

The three new PDBStorageTest cases fail the open at new Persistit(dbCfg) (:1230), so db is null when close() runs; FailedBackendOpenTest case 4 throws in the TrackedStorage wrapper before the delegate's open. Past :1230 nothing in open0 throws in production (loadVolume is a lookup of a configured volume), so deleting the monitor != null guard survives every case by construction. Either document the arm as defensive, or pin it with an injected failure past :1230 — the disk monitor is already a mock (PDBStorageTest.java:75):

@Test
public void aStorageWhoseOpenFailedAfterItsDatabaseOpenedGivesTheVolumeBack() throws Exception
{
  final DiskSpaceMonitor refusing = serverContext.getDiskSpaceMonitor();
  doThrow(new RuntimeException("disk monitor refused")).when(refusing).registerMonitoredDirectory(any(), any());
  final PDBStorage second = new PDBStorage(createBackendCfg(), serverContext);   // over a second directory
  try
  {
    second.open(AccessMode.READ_WRITE);
    fail("the storage was expected not to open when its directory cannot be monitored");
  }
  catch (RuntimeException expected)
  {
    // What the failure past the database open does.
  }
  reset(refusing);
  // The volume was given back: a third storage over the same directory opens.
  final PDBStorage third = new PDBStorage(createBackendCfg(), serverContext);
  third.open(AccessMode.READ_WRITE);
  third.close();
}

thought (non-blocking): A live db-cache-size change drifts the quota: open acquires the size at open (:1087-1095), close releases the size at close (:1125-1131), and applyConfigurationChange swaps config (:1637) without touching memQuota.

opendj-server-legacy/src/main/java/org/opends/server/backends/pdb/PDBStorage.java:1637, :1087-1095, :1125-1131

Pre-existing and outside this PR's lines — noted for a follow-up, no action here.

…k, and pin the give-back past the database open

JEStorage had the shape PDBStorage had before the previous round: the quota taken in
buildConfiguration ahead of open0, the double-open guard behind it, a failed
`new Environment` leaving the quota and the constructor's listener behind with nobody
to close the storage, and a close() which released the quota on every call - three
times on the import road, for two acquisitions. It gets the same moves: rejectIfOpen()
ahead of buildConfiguration in open() and startImport(), openOrGiveBack() around open0(),
the quota released once, an environment without a monitor tolerated.

Both storages now give back the quota, the listener and the monitored directory ahead of
the database, so that a database whose own close throws keeps nothing else.

JEStorageTest, new: the three cases of PDBStorageTest over a directory the server cannot
use - a locked directory is not a JE road inside a JVM, DbEnvPool shares the environment.
PDBStorageTest gains the case past the database open, with the disk monitor refusing the
directory: the volume, the monitor and the quota are given back.

FailedBackendOpenTest: a case whose expected failure does not come closes the backend it
opened after all, so the case which follows fails on its own assertion rather than on the
base DN the previous one left registered.
@vharseko

vharseko commented Sep 17, 2026

Copy link
Copy Markdown
Member Author

All four taken, in fd50d19; the fifth is filed as #1063. Each pin was watched to fail on the
mutant it is for before the case went in.

JEStorage - in this PR: the scope was not deliberate, and the javadoc of
giveUpAfterFailedOpen read as if it were. The moves are PDBStorage's: rejectIfOpen() ahead of
buildConfiguration in open() and in startImport() - the :903 caller, so the guard leaves
open0 for both roads as it did in PDB rather than staying there as well - openOrGiveBack()
around open0(), and close() releasing the quota once and tolerating an environment without a
monitor. Its import road too closed three times on two acquisitions. One correction to the pin: a
locked directory is not a JE road inside a JVM - DbEnvPool shares the EnvironmentImpl among the
handles a process opens over one directory, so a second Environment opens, and nothing in jeb
maps to StorageInUseException. JEStorageTest, new, fails the open on the road the round-1
comment named instead: a directory the server cannot use - a regular file where the backend
directory should be, ERR_DIRECTORY_INVALID out of setupStorageFiles once the quota is taken.
Three cases, the twins of the PDB ones, each red on its own mutant: the give-back removed (quota
short), memQuota = null removed (quota over after close()), the guard back after
buildConfiguration (quota short on the refusal).

PDBStorage.close() - the releases ahead of the database and db.close() last, in PDB and JE
both. No case for it: nothing makes a real db.close() throw, so it stays what you called it.

The db != null arm - aStorageWhoseOpenFailedAfterItsDatabaseOpenedGivesTheDatabaseBack,
with two changes to the sketch: the volume of setUp() is given up first, since createBackendCfg()
names the directory that storage holds and the open would fail on InUseException before the
database is built; and the refusing disk monitor is swapped in through serverContext rather than
reset(), since buildConfiguration asks for it on every open. Red on three mutants: the give-back
skipping close() once db is assigned (quota short), deregisterMonitorProvider deleted (the
monitor's key still registered), and one which releases everything but the database
(InUseException on the next open). The monitor != null guard stays defensive by construction -
db.initialize() after the constructor is a no-op and loadVolume a lookup - and its comment
says so.

The fixture - openExpectingFailure for the six cases whose failure is set up, returning the
closeCalls() reading tests 3 and 4 take between the failure and the close, and
openExpectingSuccess for the twin and the replay case. Your mutant re-run: the twin is now red on
its own hasSize(1) - "Expected size: 1 but was: 0" - not on the base DN.

The db-cache-size drift - agreed, pre-existing and outside these lines; filed as #1063, for
both storages - JE has the same applyConfigurationChange.

Green with the change: FailedBackendOpenTest 8/8, PDBStorageTest 14/14, JEStorageTest 3/3, and PDBTestCase, EncryptedPDBTestCase, JETestCase, EncryptedJETestCase, ReplayedConfigChangeTest, OnDiskMergeImporterTest, PersistentCompressedSchemaTest, DN2IDTest, StateTest, ID2EntryTest, ID2ChildrenCountTest, BulkCursorTest, DefaultIndexTest, ImportLDIFTestCase, RebuildIndexTestCase, VerifyIndexTestCase - the JE ones for the import road close() now sees three times there too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug index Attribute/VLV index subsystem: build, trust, rebuild, confidentiality java Changes to Java sources tests Test suites: fixing, enabling, un-disabling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A ConfigException while opening an EntryContainer leaves its five configuration listeners registered on a half-open container

2 participants